package com.ar4j;

import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;

import org.apache.commons.beanutils.PropertyUtils;
import org.springframework.core.BridgeMethodResolver;
import org.springframework.transaction.annotation.SpringTransactionAnnotationParser;
import org.springframework.transaction.interceptor.TransactionAttribute;
import org.springframework.transaction.interceptor.TransactionAttributeSource;

import com.ar4j.ActiveRecordMetadata.ActiveRecordMethodType;
import com.ar4j.annotation.Column;
import com.ar4j.annotation.Identifier;
import com.ar4j.annotation.Ignored;
import com.ar4j.annotation.SqlNamingStrategy;
import com.ar4j.annotation.Table;
import com.ar4j.annotation.Type;
import com.ar4j.sql.ISqlNamingStrategy;
import com.ar4j.type.DateWithoutMillis;
import com.ar4j.type.DateWithoutMillisTypeConverter;
import com.ar4j.type.EnumTypeConverter;
import com.ar4j.type.ITypeConverter;
import com.ar4j.type.SingleCharacterStringBooleanTypeConverter;
import com.ar4j.type.TypeConverterRegistry;
import com.ar4j.util.ClassUtils;

/**
 * Introspect a domain class to extract the active record meta data 
 *
 */
public final class ActiveRecordMetadataFactory {
  private static final String DEFAULT_ID_PROPERTY_NAME = "id";
  private static final String GET_PROPERTY_METHOD_NAME = "getProperty";
  private static final String SET_PROPERTY_METHOD_NAME = "setProperty";
  
  private static final Set<String> DEFAULT_IGNORED_PROPERTIES = new HashSet<String>(Arrays.asList(new String[] {
      "class"
    }));
  
  private static final Set<String> DEFAULT_IGNORED_METHODS = new HashSet<String>(Arrays.asList(new String[] {
      "wait", "getClass", "notify", "notifyAll"
    }));
  
  private static final Set<String> DEFAULT_METHODS_HANDLED_BY_DEFAULT_IMPLEMENTATION = new HashSet<String>(Arrays.asList(new String[] {
      "toString", "equals", "hashCode"
    }));
  
  private static ActiveRecordMetadataFactory instance;
  private static ReentrantLock instanceLock = new ReentrantLock();
  
  /**
   * Retrieve the singleton instance of the meta data factory, or create one if it does not yet exist
   */
  public static ActiveRecordMetadataFactory getInstance() {
    if(instance == null) {
      instanceLock.lock();
      try {
        if(instance == null) {
          instance = new ActiveRecordMetadataFactory();
        }
      } finally {
        instanceLock.unlock();
      }
    }
    
    return instance;
  }
  
  private ActiveRecordMetadataFactory() {
    initializeDefaultTypeConverters();
  }
  
  /**
   * Parse and extract active record meta data (should go over the entire class and its annotations)
   */
  public ActiveRecordMetadata getMetadata(Class<?> domainClass, ISqlNamingStrategy defaultNamingStrategy) {
    
    // make sure its an active record
    if(!ActiveRecord.class.isAssignableFrom(domainClass)) {
      throw new RuntimeException("Provided domain class does not implement ActiveRecord: " + domainClass.getName());
    }
    
    // make sure its an abstract class
    if(!Modifier.isAbstract(domainClass.getModifiers()) || Modifier.isInterface(domainClass.getModifiers())) {
      throw new RuntimeException("Provided domain must be abstract and not an interface: " + domainClass.getName());
    }
    
    ActiveRecordMetadata out = new ActiveRecordMetadata();
    try {
      out.setBaseClass(domainClass);
      
      // figure out the naming strategy
      ISqlNamingStrategy namingStrategy = defaultNamingStrategy;
      SqlNamingStrategy namingAnno = domainClass.getAnnotation(SqlNamingStrategy.class);
      if(namingAnno != null) {
        namingStrategy = namingAnno.value().newInstance();
      }
      
      // get table name (via annotation or naming strategy)
      String tableName = namingStrategy.getTableNameForClass(domainClass);
      Table tableAnno = domainClass.getAnnotation(Table.class);
      if(tableAnno != null) {
        tableName = tableAnno.value();
      }
      out.setTableName(tableName);
      
      // set tracking maps
      out.setMethodTypeMap( new HashMap<Method, ActiveRecordMethodType>());
      out.setDefaultImplementationMethodMap(new HashMap<Method, Method>());
      out.setDynamicMethodMetadataMap(new HashMap<Method, DynamicMethodMetadata>());
      
      // record method metadata (properties and other methods)
      recordDomainPropertyMetadata(domainClass, namingStrategy, out);
      recordNonPropertyMethodHandlers(domainClass, out);
      
      // initialize the metadata (queries and such)
      out.init();
            
    } catch(Throwable e) {
      throw new RuntimeException("Could not get metadata for: " + domainClass, e);
    }
    
    return out;
  }
  
  /**
   * Scans the given domain class for information about its properties (columns in the backing store).
   * 
   * This method is responsible for recording the metadata about:
   * property names
   * property types
   * read methods
   * write methods
   * auto-generated properties
   * identifier property
   * type conversions
   * property->column name mappings
   * 
   */
  private void recordDomainPropertyMetadata(Class<?> domainClass, ISqlNamingStrategy namingStrategy, ActiveRecordMetadata metadata) 
    throws SecurityException, NoSuchFieldException {
    
    TransactionAttributeSource transactionAttributeSource = new ActiveRecordTransactionAttributeSource(new SpringTransactionAnnotationParser());
    Map<Method, ActiveRecordMethodType> methodTypeMap = metadata.getMethodTypeMap();
    
    Set<String> propertyNames = new LinkedHashSet<String>();
    Map<String, Class<?>> propertyTypeMap = new HashMap<String, Class<?>>();
    Map<String, Method> propertyReadMethods = new HashMap<String, Method>();
    Map<String, Method> propertyWriteMethods = new HashMap<String, Method>();
    String identifierPropertyName = null;
    Map<String, ITypeConverter<?, ?>> propertyTypeConvertorMap = new HashMap<String, ITypeConverter<?, ?>>();
    Map<String, String> propertyColumnNameMap = new HashMap<String, String>();
    Map<Method, TransactionAttribute> methodTransactionAttributeMap = new HashMap<Method, TransactionAttribute>();
    for(PropertyDescriptor property : PropertyUtils.getPropertyDescriptors(domainClass)) {
      if(property.isHidden() || DEFAULT_IGNORED_PROPERTIES.contains(property.getName())) {
        continue;
      }
      
      Method readMethod = property.getReadMethod();
      if(readMethod != null && Modifier.isPublic(readMethod.getModifiers())) {
        if(readMethod.isBridge()) {
          readMethod = BridgeMethodResolver.findBridgedMethod(readMethod);
        }
        
        methodTypeMap.put(readMethod, ActiveRecordMethodType.PASSTHROUGH);
        propertyReadMethods.put(property.getName(), readMethod);
        
        // extract annotation from method only, no default transaction attribute from the domain class
        TransactionAttribute transactionAttribute = transactionAttributeSource.getTransactionAttribute(readMethod, null);
        if(transactionAttribute != null) {
          methodTransactionAttributeMap.put(readMethod, transactionAttribute);
        }
      }
      
      Method writeMethod = property.getWriteMethod();
      if(writeMethod != null && Modifier.isPublic(writeMethod.getModifiers())) {
        if(writeMethod.isBridge()) {
          writeMethod = BridgeMethodResolver.findBridgedMethod(writeMethod);
        }
        
        methodTypeMap.put(writeMethod, ActiveRecordMethodType.PASSTHROUGH);
        propertyWriteMethods.put(property.getName(), writeMethod);
        
        // extract annotation from method only, no default transaction attribute from the domain class
        TransactionAttribute transactionAttribute = transactionAttributeSource.getTransactionAttribute(writeMethod, null);
        if(transactionAttribute != null) {
          methodTransactionAttributeMap.put(writeMethod, transactionAttribute);
        }
      }
      
      // only process the property in full if it isn't ignored 
      Ignored ignoredAnno = ClassUtils.getPropertyAnnotation(Ignored.class, domainClass, property);
      if(ignoredAnno == null) {
        propertyNames.add(property.getName());
        propertyTypeMap.put(property.getName(), property.getPropertyType());
        
        // check for identifier annotation on field or read method
        Identifier idAnno = ClassUtils.getPropertyAnnotation(Identifier.class, domainClass, property);
        if(idAnno != null) {
          if(identifierPropertyName == null) {
            identifierPropertyName = property.getName();
          } else {
            throw new RuntimeException("Cannot have two identifiers in an active record: " + identifierPropertyName + ", " + property.getName());
          }
        }
        
        // check for type converter
        Type typeAnno = ClassUtils.getPropertyAnnotation(Type.class, domainClass, property);
        if(typeAnno != null) {
          ITypeConverter<?, ?> converter = getTypeConverterFromAnnotation(domainClass, property, typeAnno);
          propertyTypeConvertorMap.put(property.getName(), converter);
        } else {
          ITypeConverter<?, ?> defaultConverter = TypeConverterRegistry.getInstance().getDefaultTypeConverter(property.getPropertyType());
          propertyTypeConvertorMap.put(property.getName(), defaultConverter);
        }
        
        // decipher column name, or use default
        String column = null;
        Column columnAnno = ClassUtils.getPropertyAnnotation(Column.class, domainClass, property);
        if(columnAnno != null) {
          column = columnAnno.value();
        } else {
          column = namingStrategy.getColumnNameForPropertyName(property.getName());
        }
        propertyColumnNameMap.put(property.getName(), column);
      }
    }
    
    // set to default ID if needed
    if(identifierPropertyName == null) {
      identifierPropertyName = DEFAULT_ID_PROPERTY_NAME;
    }
    
    // validate identifier property type
    Class<?> identifierType = propertyTypeMap.get(identifierPropertyName);
    if(!Number.class.isAssignableFrom(identifierType)) {
      throw new IllegalArgumentException("Identifier property (" + identifierPropertyName + ") must be assignable to Number");
    }
    
    metadata.setPropertyNames(propertyNames);
    metadata.setPropertyTypeMap(propertyTypeMap);
    metadata.setPropertyReadMethods(propertyReadMethods);
    metadata.setPropertyWriteMethods(propertyWriteMethods);
    metadata.setIdentifierPropertyName(identifierPropertyName);
    metadata.setPropertyTypeConvertorMap(propertyTypeConvertorMap);
    metadata.setPropertyColumnNameMap(propertyColumnNameMap);
    metadata.setMethodTransactionAttributeMap(methodTransactionAttributeMap);
    
  }
  
  /**
   * Scans the given domain class and records metadata for all methods.
   */
  private void recordNonPropertyMethodHandlers(Class<?> domainClass, ActiveRecordMetadata metadata) 
    throws SecurityException, NoSuchMethodException {
    
    Map<Method, ActiveRecordMethodType> methodTypeMap = metadata.getMethodTypeMap();
    Map<Method, Method> defaultImplementationMethodMap = metadata.getDefaultImplementationMethodMap();
    Map<Method, DynamicMethodMetadata> dynamicMetadataMap = metadata.getDynamicMethodMetadataMap();
    
    // transaction attribute extraction setup
    TransactionAttributeSource transactionAttributeSource = new ActiveRecordTransactionAttributeSource(new SpringTransactionAnnotationParser());
    Map<Method, TransactionAttribute> methodTransactionAttributeMap = metadata.getMethodTransactionAttributeMap();
    if(methodTransactionAttributeMap == null) {
      methodTransactionAttributeMap = new HashMap<Method, TransactionAttribute>();
      metadata.setMethodTransactionAttributeMap(methodTransactionAttributeMap);
    }
    
    // go through all the methods and figure out their types, extract dynamic metadata as needed
    for(Method method : domainClass.getMethods()) {
      
      // ignore some methods by default or anything thats already been mapped to a type 
      // (i.e. getters/setters)
      if(DEFAULT_IGNORED_METHODS.contains(method.getName()) || methodTypeMap.containsKey(method)) {
        continue;
      }
      
      ActiveRecordMethodType type = null;
      boolean transactionAtMethodLevelOnly = false;
      
      // anything ignored gets a passthrough
      if(method.getAnnotation(Ignored.class) != null && !Modifier.isAbstract(method.getModifiers())) {
        type = ActiveRecordMethodType.PASSTHROUGH;
      }
      
      // detect property getting method
      else if(GET_PROPERTY_METHOD_NAME.equals(method.getName())) {
        type = ActiveRecordMethodType.GET_PROPERTY;
        transactionAtMethodLevelOnly = true;
      } 
      
      // detect property setting method
      else if(SET_PROPERTY_METHOD_NAME.equals(method.getName())) {
        type = ActiveRecordMethodType.SET_PROPERTY;
        transactionAtMethodLevelOnly = true;
      }
      
      // anything declared in the interface or is toString/equals/hashCode is handled by the default implementation
      else if (method.getDeclaringClass() == ActiveRecord.class || DEFAULT_METHODS_HANDLED_BY_DEFAULT_IMPLEMENTATION.contains(method.getName())) {
        type = ActiveRecordMethodType.HANDLED_BY_DEFAULT_IMPLEMENTATION;
        Method defaultMethod = DefaultActiveRecord.class.getMethod(method.getName(), method.getParameterTypes());
        defaultImplementationMethodMap.put(method, defaultMethod);
      }
            
      // if it wasn't declared in the interface and its concrete, its a passthrough
      else if (Modifier.isPublic(method.getModifiers()) && !Modifier.isAbstract(method.getModifiers())) {
        type = ActiveRecordMethodType.PASSTHROUGH;
      }
      
      // anything not in the interface and abstract must be a dynamic method
      else if (Modifier.isPublic(method.getModifiers()) && Modifier.isAbstract(method.getModifiers())) {
        type = ActiveRecordMethodType.DYNAMIC;
        DynamicMethodMetadata dynamicMetadata = DynamicMethodMetadataFactory.getDynamicMethodMetadata(method, metadata);
        dynamicMetadataMap.put(method, dynamicMetadata);
      } 
      
      // fail otherwise
      else {
        throw new RuntimeException("Could not decide on type of active record method for: " + method);
      }
      
      // extract transaction attribute
      Class<?> containingClassToUse = (transactionAtMethodLevelOnly ? null : domainClass);
      TransactionAttribute transactionAttribute = transactionAttributeSource.getTransactionAttribute(method, containingClassToUse);
      if(transactionAttribute != null) {
        methodTransactionAttributeMap.put(method, transactionAttribute);
      }
      
      // record type
      methodTypeMap.put(method, type);
      
    }
  }
  
  /**
   * Creates a new type converter from the given Type annotation for the given
   * property in the domain class.
   */
  @SuppressWarnings("unchecked")
  private <E, F> ITypeConverter<E, F> getTypeConverterFromAnnotation(Class<?> domainClass, PropertyDescriptor property, Type typeAnno) {
    Class<?> converterClass = typeAnno.converterClass();
    if(!ITypeConverter.class.isAssignableFrom(converterClass)) {
      throw new IllegalArgumentException("Converter class provided is not a subclass of ITypeConverter. property: " + property.getName() + " in class " +
          domainClass.getName() + " specified converter class: " + converterClass.getName());
    }
    
    ITypeConverter<E, F> out = null;
    try {
      out = (ITypeConverter<E, F>) converterClass.newInstance();
    } catch(Throwable e) {
      throw new RuntimeException("Could not create a new converter of type " + converterClass.getName() + " for " + property.getName() + " in class " + domainClass.getName(), e);
    }
    
    return out;
  }
  
  /**
   * Register some default type converters in the TypeConverterRegistry
   */
  @SuppressWarnings({ "rawtypes" })
  private void initializeDefaultTypeConverters() {
    TypeConverterRegistry.getInstance()
      .registerDefaultTypeConverter(DateWithoutMillis.class, new DateWithoutMillisTypeConverter())
      .registerDefaultTypeConverter(Enum.class, new EnumTypeConverter())
      .registerDefaultTypeConverter(Boolean.class, new SingleCharacterStringBooleanTypeConverter());
    
  }
  
}
